/*
 * Licensed to the Apache Software Foundation (ASF) under one
 * or more contributor license agreements.  See the NOTICE file
 * distributed with this work for additional information
 * regarding copyright ownership.  The ASF licenses this file
 * to you under the Apache License, Version 2.0 (the
 * "License"); you may not use this file except in compliance
 * with the License.  You may obtain a copy of the License at
 *
 *   http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing,
 * software distributed under the License is distributed on an
 * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
 * KIND, either express or implied.  See the License for the
 * specific language governing permissions and limitations
 * under the License.
 */
package org.apache.olingo.client.core.uri;

import org.apache.commons.codec.binary.Hex;
import org.apache.commons.io.IOUtils;
import org.apache.commons.lang3.BooleanUtils;
import org.apache.commons.lang3.StringUtils;
import org.apache.http.HttpEntity;
import org.apache.http.client.utils.URIBuilder;
import org.apache.http.entity.AbstractHttpEntity;
import org.apache.http.entity.ByteArrayEntity;
import org.apache.http.entity.InputStreamEntity;
import org.apache.olingo.client.api.CommonODataClient;
import org.apache.olingo.client.api.http.HttpClientFactory;
import org.apache.olingo.client.api.uri.SegmentType;
import org.apache.olingo.client.core.http.BasicAuthHttpClientFactory;
import org.apache.olingo.commons.api.Constants;
import org.apache.olingo.commons.api.edm.EdmPrimitiveTypeException;
import org.apache.olingo.commons.api.edm.EdmPrimitiveTypeKind;
import org.apache.olingo.commons.api.edm.constants.ODataServiceVersion;
import org.apache.olingo.commons.api.edm.geo.Geospatial;
import org.apache.olingo.commons.core.edm.primitivetype.EdmBinary;
import org.apache.olingo.commons.core.edm.primitivetype.EdmDateTime;
import org.apache.olingo.commons.core.edm.primitivetype.EdmDateTimeOffset;
import org.apache.olingo.commons.core.edm.primitivetype.EdmDecimal;
import org.apache.olingo.commons.core.edm.primitivetype.EdmDouble;
import org.apache.olingo.commons.core.edm.primitivetype.EdmDuration;
import org.apache.olingo.commons.core.edm.primitivetype.EdmInt64;
import org.apache.olingo.commons.core.edm.primitivetype.EdmPrimitiveTypeFactory;
import org.apache.olingo.commons.core.edm.primitivetype.EdmSingle;
import org.apache.olingo.commons.core.edm.primitivetype.EdmTime;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import javax.xml.datatype.Duration;
import java.io.IOException;
import java.io.InputStream;
import java.io.UnsupportedEncodingException;
import java.math.BigDecimal;
import java.net.URI;
import java.net.URISyntaxException;
import java.net.URLEncoder;
import java.sql.Timestamp;
import java.util.Calendar;
import java.util.Collection;
import java.util.Iterator;
import java.util.Map;
import java.util.UUID;
import java.util.regex.Pattern;
import org.apache.olingo.client.api.http.WrappingHttpClientFactory;
import org.apache.olingo.commons.api.domain.ODataValue;

/**
 * URI utilities.
 */
public final class URIUtils {

  /**
   * Logger.
   */
  private static final Logger LOG = LoggerFactory.getLogger(URIUtils.class);

  private static final Pattern ENUM_VALUE = Pattern.compile("(.+\\.)?.+'.+'");

  private URIUtils() {
    // Empty private constructor for static utility classes
  }

  /**
   * Build URI starting from the given base and href.
   * <br/>
   * If href is absolute or base is null then base will be ignored.
   *
   * @param base URI prefix.
   * @param href URI suffix.
   * @return built URI.
   */
  public static URI getURI(final String base, final String href) {
    if (href == null) {
      throw new IllegalArgumentException("Null link provided");
    }

    URI uri = URI.create(href);

    if (!uri.isAbsolute() && base != null) {
      uri = URI.create(base + "/" + href);
    }

    return uri.normalize();
  }

  /**
   * Build URI starting from the given base and href.
   * <br/>
   * If href is absolute or base is null then base will be ignored.
   *
   * @param base URI prefix.
   * @param href URI suffix.
   * @return built URI.
   */
  public static URI getURI(final URI base, final URI href) {
    if (href == null) {
      throw new IllegalArgumentException("Null link provided");
    }
    return getURI(base, href.toASCIIString());
  }

  /**
   * Build URI starting from the given base and href.
   * <br/>
   * If href is absolute or base is null then base will be ignored.
   *
   * @param base URI prefix.
   * @param href URI suffix.
   * @return built URI.
   */
  public static URI getURI(final URI base, final String href) {
    if (href == null) {
      throw new IllegalArgumentException("Null link provided");
    }

    URI uri = URI.create(href);

    if (!uri.isAbsolute() && base != null) {
      uri = URI.create(base.toASCIIString() + "/" + href);
    }

    return uri.normalize();
  }

  private static String prefix(final ODataServiceVersion version, final EdmPrimitiveTypeKind typeKind) {
    String result = StringUtils.EMPTY;
    if (version.compareTo(ODataServiceVersion.V40) < 0) {
      switch (typeKind) {
        case Guid:
          result = "guid'";
          break;

        case DateTime:
          result = "datetime'";
          break;

        case DateTimeOffset:
          result = "datetimeoffset'";
          break;

        case Binary:
          result = "X'";
          break;

        default:
      }
    }

    return result;
  }

  private static String suffix(final ODataServiceVersion version, final EdmPrimitiveTypeKind typeKind) {
    String result = StringUtils.EMPTY;
    if (version.compareTo(ODataServiceVersion.V40) < 0) {
      switch (typeKind) {
        case Guid:
        case DateTime:
        case DateTimeOffset:
          result = "'";
          break;

        case Decimal:
          result = "M";
          break;

        case Double:
          result = "D";
          break;

        case Single:
          result = "f";
          break;

        case Int64:
          result = "L";
          break;

        default:
      }
    }

    return result;
  }

  private static String timestamp(final ODataServiceVersion version, final Timestamp timestamp)
          throws UnsupportedEncodingException, EdmPrimitiveTypeException {

    return version.compareTo(ODataServiceVersion.V40) < 0
            ? prefix(version, EdmPrimitiveTypeKind.DateTime)
            + URLEncoder.encode(EdmDateTime.getInstance().
            valueToString(timestamp, null, null, Constants.DEFAULT_PRECISION, Constants.DEFAULT_SCALE, null),
            Constants.UTF8)
            + suffix(version, EdmPrimitiveTypeKind.DateTime)
            : URLEncoder.encode(EdmDateTimeOffset.getInstance().
            valueToString(timestamp, null, null, Constants.DEFAULT_PRECISION, Constants.DEFAULT_SCALE, null),
            Constants.UTF8);
  }

  private static String calendar(final ODataServiceVersion version, final Calendar calendar)
          throws UnsupportedEncodingException, EdmPrimitiveTypeException {

    return version.compareTo(ODataServiceVersion.V40) < 0
            ? prefix(version, EdmPrimitiveTypeKind.DateTime)
            + URLEncoder.encode(EdmDateTime.getInstance().
            valueToString(calendar, null, null, Constants.DEFAULT_PRECISION, Constants.DEFAULT_SCALE, null),
            Constants.UTF8)
            + suffix(version, EdmPrimitiveTypeKind.DateTime)
            : URLEncoder.encode(EdmDateTimeOffset.getInstance().
            valueToString(calendar, null, null, Constants.DEFAULT_PRECISION, Constants.DEFAULT_SCALE, null),
            Constants.UTF8);
  }

  private static String duration(final ODataServiceVersion version, final Duration duration)
          throws UnsupportedEncodingException, EdmPrimitiveTypeException {

    return version.compareTo(ODataServiceVersion.V40) < 0
            ? EdmTime.getInstance().toUriLiteral(URLEncoder.encode(EdmTime.getInstance().
            valueToString(duration, null, null,
            Constants.DEFAULT_PRECISION, Constants.DEFAULT_SCALE, null), Constants.UTF8))
            : EdmDuration.getInstance().toUriLiteral(URLEncoder.encode(EdmDuration.getInstance().
            valueToString(duration, null, null,
            Constants.DEFAULT_PRECISION, Constants.DEFAULT_SCALE, null), Constants.UTF8));
  }

  private static String quoteString(final String string, final boolean singleQuoteEscape)
          throws UnsupportedEncodingException {

    return ENUM_VALUE.matcher(string).matches()
            ? string
            : singleQuoteEscape
            ? "'" + string + "'"
            : "\"" + string + "\"";
  }

  /**
   * Turns primitive values into their respective URI representation.
   *
   * @param version OData protocol version
   * @param obj primitive value
   * @return URI representation
   */
  public static String escape(final ODataServiceVersion version, final Object obj) {
    return escape(version, obj, true);
  }

  private static String escape(final ODataServiceVersion version, final Object obj, final boolean singleQuoteEscape) {
    String value;

    try {
      if (obj == null) {
        value = Constants.ATTR_NULL;
      } else if (version.compareTo(ODataServiceVersion.V40) >= 0 && obj instanceof Collection) {
        final StringBuffer buffer = new StringBuffer("[");
        for (@SuppressWarnings("unchecked")
                final Iterator<Object> itor = ((Collection<Object>) obj).iterator(); itor.hasNext();) {
          buffer.append(escape(version, itor.next(), false));
          if (itor.hasNext()) {
            buffer.append(',');
          }
        }
        buffer.append(']');

        value = buffer.toString();
      } else if (version.compareTo(ODataServiceVersion.V40) >= 0 && obj instanceof Map) {
        final StringBuffer buffer = new StringBuffer("{");
        for (@SuppressWarnings("unchecked")
                final Iterator<Map.Entry<String, Object>> itor =
                ((Map<String, Object>) obj).entrySet().iterator(); itor.hasNext();) {

          final Map.Entry<String, Object> entry = itor.next();
          buffer.append("\"").append(entry.getKey()).append("\"");
          buffer.append(':').append(escape(version, entry.getValue(), false));

          if (itor.hasNext()) {
            buffer.append(',');
          }
        }
        buffer.append('}');

        value = buffer.toString();
      } else {
        value = (obj instanceof ParameterAlias)
                ? "@" + ((ParameterAlias) obj).getAlias()
                : (obj instanceof Boolean)
                ? BooleanUtils.toStringTrueFalse((Boolean) obj)
                : (obj instanceof UUID)
                ? prefix(version, EdmPrimitiveTypeKind.Guid)
                + obj.toString()
                + suffix(version, EdmPrimitiveTypeKind.Guid)
                : (obj instanceof byte[])
                ? EdmBinary.getInstance().toUriLiteral(Hex.encodeHexString((byte[]) obj))
                : (obj instanceof Timestamp)
                ? timestamp(version, (Timestamp) obj)
                : (obj instanceof Calendar)
                ? calendar(version, (Calendar) obj)
                : (obj instanceof Duration)
                ? duration(version, (Duration) obj)
                : (obj instanceof BigDecimal)
                ? EdmDecimal.getInstance().valueToString(obj, null, null,
                Constants.DEFAULT_PRECISION, Constants.DEFAULT_SCALE, null)
                + suffix(version, EdmPrimitiveTypeKind.Decimal)
                : (obj instanceof Double)
                ? EdmDouble.getInstance().valueToString(obj, null, null,
                Constants.DEFAULT_PRECISION, Constants.DEFAULT_SCALE, null)
                + suffix(version, EdmPrimitiveTypeKind.Double)
                : (obj instanceof Float)
                ? EdmSingle.getInstance().valueToString(obj, null, null,
                Constants.DEFAULT_PRECISION, Constants.DEFAULT_SCALE, null)
                + suffix(version, EdmPrimitiveTypeKind.Single)
                : (obj instanceof Long)
                ? EdmInt64.getInstance().valueToString(obj, null, null,
                Constants.DEFAULT_PRECISION, Constants.DEFAULT_SCALE, null)
                + suffix(version, EdmPrimitiveTypeKind.Int64)
                : (obj instanceof Geospatial)
                ? URLEncoder.encode(EdmPrimitiveTypeFactory.getInstance(((Geospatial) obj).getEdmPrimitiveTypeKind()).
                valueToString(obj, null, null, Constants.DEFAULT_PRECISION, Constants.DEFAULT_SCALE, null),
                Constants.UTF8)
                : (obj instanceof String)
                ? quoteString((String) obj, singleQuoteEscape)
                : obj.toString();
      }
    } catch (Exception e) {
      LOG.warn("While escaping '{}', using toString()", obj, e);
      value = obj.toString();
    }

    return value;
  }

  public static boolean shouldUseRepeatableHttpBodyEntry(final CommonODataClient<?> client) {
    // returns true for authentication request in case of http401 which needs retry so requires being repeatable.
    HttpClientFactory httpclientFactory = client.getConfiguration().getHttpClientFactory();
    if (httpclientFactory instanceof BasicAuthHttpClientFactory) {
      return true;
    } else if (httpclientFactory instanceof WrappingHttpClientFactory) {
      WrappingHttpClientFactory tmp = (WrappingHttpClientFactory) httpclientFactory;
      if (tmp.getWrappedHttpClientFactory() instanceof BasicAuthHttpClientFactory) {
        return true;
      }
    }

    return false;
  }

  public static HttpEntity buildInputStreamEntity(final CommonODataClient<?> client, final InputStream input) {
    AbstractHttpEntity entity;
    boolean useChunked = client.getConfiguration().isUseChuncked();

    if (shouldUseRepeatableHttpBodyEntry(client) || !useChunked) {
      byte[] bytes = new byte[0];
      try {
        bytes = IOUtils.toByteArray(input);
        IOUtils.closeQuietly(input);
      } catch (IOException e) {
        LOG.error("While reading input for not chunked encoding", e);
      }

      entity = new ByteArrayEntity(bytes);
    } else {
      entity = new InputStreamEntity(input, -1);
    }


    if (!useChunked && entity.getContentLength() < 0) {
      LOG.error("Could not determine length - request will be sent as chunked.");
      useChunked = true;
    }
    // both entities can be sent in chunked way or not
    entity.setChunked(useChunked);

    return entity;
  }

  public static URI addValueSegment(final URI uri) {
    final URI res;
    if (uri.getPath().endsWith(SegmentType.VALUE.getValue())) {
      res = uri;
    } else {
      try {
        res = new URIBuilder(uri).setPath(uri.getPath() + "/" + SegmentType.VALUE.getValue()).build();
      } catch (URISyntaxException e) {
        throw new IllegalArgumentException(e);
      }
    }

    return res;
  }

  public static URI buildFunctionInvokeURI(
          final URI uri, final Map<String, ODataValue> parameters, final ODataServiceVersion serviceVersion) {

    if (serviceVersion.compareTo(ODataServiceVersion.V40) >= 0) {
      final String rawQuery = uri.getRawQuery();
      String baseURI = StringUtils.substringBefore(uri.toASCIIString(), "?" + rawQuery);
      if (baseURI.endsWith("()")) {
        baseURI = baseURI.substring(0, baseURI.length() - 2);
      }

      final StringBuilder inlineParams = new StringBuilder();
      for (Map.Entry<String, ODataValue> param : parameters.entrySet()) {
        inlineParams.append(param.getKey()).append("=");

        Object value = null;
        if (param.getValue().isPrimitive()) {
          value = param.getValue().asPrimitive().toValue();
        } else if (param.getValue().isComplex()) {
          value = param.getValue().asComplex().asJavaMap();
        } else if (param.getValue().isCollection()) {
          value = param.getValue().asCollection().asJavaCollection();
        } else if (param.getValue() instanceof org.apache.olingo.commons.api.domain.v4.ODataValue
                && ((org.apache.olingo.commons.api.domain.v4.ODataValue) param.getValue()).isEnum()) {

          value = ((org.apache.olingo.commons.api.domain.v4.ODataValue) param.getValue()).asEnum().toString();
        }

        inlineParams.append(URIUtils.escape(serviceVersion, value)).append(',');
      }
      
      if (inlineParams.length() > 0) {
        inlineParams.deleteCharAt(inlineParams.length() - 1);
      }

      try {
        return URI.create(baseURI + "(" + URLEncoder.encode(inlineParams.toString(), Constants.UTF8) + ")"
                + (StringUtils.isNotBlank(rawQuery) ? "?" + rawQuery : StringUtils.EMPTY));
      } catch (UnsupportedEncodingException e) {
        throw new IllegalArgumentException("While adding GET parameters", e);
      }
    } else {
      final URIBuilder uriBuilder = new URIBuilder(uri);
      for (Map.Entry<String, ODataValue> param : parameters.entrySet()) {
        if (!param.getValue().isPrimitive()) {
          throw new IllegalArgumentException("Only primitive values can be passed via GET");
        }

        uriBuilder.addParameter(param.getKey(), URIUtils.escape(serviceVersion, param.getValue()));
      }

      try {
        return uriBuilder.build();
      } catch (URISyntaxException e) {
        throw new IllegalArgumentException("While adding GET parameters", e);
      }
    }
  }
}
